package org.jtsmix.mixfile;

import java.io.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Mix archive header
 * <p/>
 * contains number of files information, flags and body size<br />
 * <p/>
 * <p/>
 * [4B] - flags (optional) <br />
 * [2B] - number of files  <br />
 * [4B] - body size  <br />
 * [80B] - key source (only if encrypted)  <br />
 * file index follows
 *
 * @author Ivosh
 */

public class MixFileHeader {
    private static final int KEY_SOURCE_SIZE = 80;
    private static final int KEY_SIZE = 56;
    private static final int MINIMAL_MIX_HEADER_SIZE = 6;
    private static final int MIX_FILE_INDEX_ENTRY_SIZE = 12;


    private Logger logger = Logger.getLogger("jtsmix.mixfile");
    private int numFiles;
    private long size;
    private byte[] flags = {0, 0, 0, 0};
    private byte[] keySource = new byte[KEY_SOURCE_SIZE];
    private byte[] key = new byte[KEY_SIZE];
    private Map<Long, MixFileIndexEntry> mixFileIndex = new HashMap<Long, MixFileIndexEntry>();
    private long dataOffset = MINIMAL_MIX_HEADER_SIZE;

    /**
     * @return mix file body offset (= size of headers)
     */
    public long getDataOffset() {
        return dataOffset;
    }


    /**
     * add file to index
     *
     * @param crcId crc id counted from filename
     * @param size  size of file
     * @return false if file already present
     */
    public boolean addFile(long crcId, long size) {
        MixFileIndexEntry file = new MixFileIndexEntry(crcId, this.size, size);
        if (contains(file.getCrcId())) {
            return false;
        }
        mixFileIndex.put(file.getCrcId(), file);
        this.size += file.getSize();
        this.numFiles++;
        this.dataOffset += MIX_FILE_INDEX_ENTRY_SIZE;
        return true;
    }

    /**
     * remove file from index
     *
     * @param crcId crc id counted from filename
     * @return false if file not present
     */
    public boolean removeFile(long crcId) {
        if (!contains(crcId)) {
            return false;
        }

        MixFileIndexEntry file = mixFileIndex.remove(crcId);

        for (long k : mixFileIndex.keySet()) {
            MixFileIndexEntry setFile = mixFileIndex.get(k);
            if (setFile.getOffset() > file.getOffset()) {
                setFile.setOffset(setFile.getOffset() - file.getSize());
            }
        }
        this.dataOffset -= MIX_FILE_INDEX_ENTRY_SIZE;
        this.size -= file.getSize();
        this.numFiles--;
        return true;
    }

    protected boolean hasFlags() {
        return flags[2] != 0;
    }

    public int getNumFiles() {
        return this.numFiles;
    }

    public long getSize() {
        return this.size;
    }

    public final boolean isEncrypted() {
        return (flags[2] & 2) != 0;
    }

    public boolean hasCheckSum() {
        return (flags[2] & 1) != 0;
    }

//    private boolean flagsEmpty() {
//        return (flags[0] == 0) && (flags[0] == 0) && (flags[0] == 0) && (flags[0] == 0);
//    }

    /**
     * writes header to stream
     * <p/>
     * This method doesn't care about file index.
     *
     * @param os stream to write header
     * @throws IOException
     */
    public void writeHeader(OutputStream os) throws IOException {
        if (hasFlags()) {
            os.write(flags);
        }
        if (isEncrypted()) {
            os.write(keySource);
            writeEncryptedHeader(os);
        } else {
            writeUnencryptedHeader(os);
        }
    }

    /**
     * makes header and file index from stream
     * <p/>
     * This is the only safe header constructor.
     *
     * @param is stream from which header is read
     * @throws IOException
     */
    public MixFileHeader(InputStream is)
            throws IOException {

        DataInputStream stream = new DataInputStream(is);
        int buffer[] = new int[4];
        ByteArrayOutputStream decryptedOutputStream = null;
        for (int i = 0; i < buffer.length; i++) {
            buffer[i] = stream.readUnsignedByte();
        }

        if (buffer[0] == 0 && buffer[1] == 0) { // archive has flags
            dataOffset += 4;
            this.flags[0] = (byte) buffer[0];
            this.flags[1] = (byte) buffer[1];
            this.flags[2] = (byte) buffer[2];
            this.flags[3] = (byte) buffer[3];

            if (isEncrypted()) {
                dataOffset += 80;
                stream.read(keySource);

/*                for (int i = 0; i < 80; i += 10) {
                    StringBuilder whex = new StringBuilder();
                    for (int j = 0; j < 10; j++) {
                        String hex = Integer.toHexString(0xFF & keySource[i + j]);
                        if (hex.length() == 1) {
                            hex = '0' + hex;
                        }
                        whex.append(hex + " ");
                    }
                    System.out.println(whex);
                }*/

                try {
                    key = WestwoodKey.get_blowfish_key(keySource);
                } catch (Exception e) {
                    e.printStackTrace();  //To change body of catch statement use File | Settings | File Templates.
                }

                decryptedOutputStream = new ByteArrayOutputStream();
                readEncryptedHeader(stream, decryptedOutputStream);
                decryptFileIndex(stream, decryptedOutputStream);
            } else {
                // read next 2B to get number of files
                this.numFiles = stream.readUnsignedByte();
                this.numFiles += (stream.readUnsignedByte() << 8);

                // read mix body size
                for (int i = 0; i < 4; i++) {
                    size += (stream.readUnsignedByte() << i * 8);
                }
            }
        } else {
            int size2[] = {stream.readUnsignedByte(), stream.readUnsignedByte()};
            this.numFiles = buffer[0] + (buffer[1] << 8);
            this.size = buffer[2] + (buffer[3] << 8) + (size2[0] << 16) + (size2[1] << 24);
        }

        logger.log(Level.INFO, "Number of files: {0} size: {1}.", new Object[]{this.numFiles, this.size});

        if (isEncrypted()) {
            readFileIndex(outputToInput(decryptedOutputStream));
        } else {
            readFileIndex(stream);
        }
    }

    /**
     * reads encrypted header
     *
     * @param stream encrypted data
     * @param dos    decrypted file index data (2B)
     * @throws IOException
     */
    private void readEncryptedHeader(InputStream stream, OutputStream dos) throws IOException {
        byte encrypted[] = new byte[8];
        byte decrypted[];
        if (stream.read(encrypted) != 8) {
            logger.log(Level.SEVERE, "Mix file seems to be corrupted.");
        }

        decrypted = MixEncryption.decryptBlowfish(encrypted, key);
        if (decrypted == null) {
            logger.log(Level.SEVERE, "Decryption failed.");
        }
        this.numFiles = decrypted[0] + (decrypted[1] << 8);
        this.size = decrypted[2] + (decrypted[3] << 8) + (decrypted[4] << 16) + (decrypted[5] << 24);
        dos.write(decrypted, 6, 2);

    }

    /**
     * encrypt header data and write it to stream
     *
     * @param os output (for example FileOutputStream)
     * @throws IOException
     */
    private void writeEncryptedHeader(OutputStream os) throws IOException {
        byte decrypted[] = new byte[8];
        byte encrypted[];
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        for (long k : mixFileIndex.keySet()) {
            mixFileIndex.get(k).writeEntry(bos);
        }
        ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());

        decrypted[0] = (byte) (this.numFiles & 0x00FF);
        decrypted[1] = (byte) ((this.numFiles & 0xFF00) >>> 8);

        decrypted[2] = (byte) (this.size & 0x000000FF);
        decrypted[3] = (byte) ((this.size & 0x0000FF00) >>> 8);
        decrypted[4] = (byte) ((this.size & 0x00FF0000) >>> 16);
        decrypted[5] = (byte) ((this.size & 0xFF000000) >>> 24);
        decrypted[6] = (byte) bis.read();
        decrypted[7] = (byte) bis.read();

        MixEncryption encryptor = new MixEncryption(key);
        encrypted = encryptor.encryptBlowfish(decrypted);
        os.write(encrypted);

        while (bis.read(decrypted) != -1) {
            encrypted = encryptor.encryptBlowfish(decrypted);
            os.write(encrypted);
        }
    }

    private void writeUnencryptedHeader(OutputStream os) throws IOException {
        byte decrypted[] = new byte[6];

        decrypted[0] = (byte) (this.numFiles & 0x00FF);
        decrypted[1] = (byte) ((this.numFiles & 0xFF00) >>> 8);

        decrypted[2] = (byte) (this.size & 0x000000FF);
        decrypted[3] = (byte) ((this.size & 0x0000FF00) >>> 8);
        decrypted[4] = (byte) ((this.size & 0x00FF0000) >>> 16);
        decrypted[5] = (byte) ((this.size & 0xFF000000) >>> 24);

        os.write(decrypted);
        for (long k : mixFileIndex.keySet()) {
            mixFileIndex.get(k).writeEntry(os);
        }
    }

    /**
     * converts outputstream to inputstream
     *
     * @param os outputstream to convert
     * @return
     */
    private static ByteArrayInputStream outputToInput(ByteArrayOutputStream os) {
        return new ByteArrayInputStream(os.toByteArray());
    }

    private void readFileIndex(InputStream is) throws IOException {
        for (int i = 0; i < this.numFiles; i++) {
            MixFileIndexEntry newItem;
            newItem = new MixFileIndexEntry(is);
            // TODO remove
            mixFileIndex.put(newItem.getCrcId(), newItem);
            dataOffset += 12;
        }
    }

    private void decryptFileIndex(InputStream ois, OutputStream dos) throws IOException {
        int iMax = (this.numFiles * 12 - 2) / 8;
        int remainingBytes = (this.numFiles * 12 - 2) % 8;
        byte encrypted[] = new byte[8];
        byte decrypted[];
        MixEncryption decryptor = new MixEncryption(key);
        for (int i = 0; i < iMax; i++) {
            ois.read(encrypted);
            decrypted = decryptor.decryptBlowfish(encrypted);
            dos.write(decrypted);
        }
        if (remainingBytes > 0) {
            ois.read(encrypted);
            decrypted = decryptor.decryptBlowfish(encrypted);
            dos.write(decrypted, 0, remainingBytes);

            dataOffset += 8 - remainingBytes;
        }
    }

    /**
     * @param fileName file name
     * @return true if file is present
     */
    public boolean contains(String fileName, GameType gameType) {
        long l = CRCCounter.getWestwoodCRC(fileName, gameType);
        for (long k : mixFileIndex.keySet()) {
            if (mixFileIndex.get(k).getCrcId() == l) {
                return true;
            }
        }
        return mixFileIndex.containsKey(CRCCounter.getWestwoodCRC(fileName, gameType));
    }

    /**
     * @param crcID crc id counted from file name
     * @return true if file is present
     */
    public boolean contains(long crcID) {
        return mixFileIndex.containsKey(crcID);
    }

    /**
     * @param fileName file name
     * @return file index entry
     */
    public MixFileIndexEntry getFileIndexEntry(String fileName, GameType gameType) {
        return mixFileIndex.get(CRCCounter.getWestwoodCRC(fileName, gameType));
    }

    /**
     * @return arraylist of contained files
     */
    public List<MixFileIndexEntry> getFileList() {
        return new ArrayList<MixFileIndexEntry>(mixFileIndex.values());
    }
}